Plotly in a standalone Slate notebook
Existing PlotlyJS.jl code, rendered by Slate and exported to one HTML file that opens with no
Julia, no server and no network — figures fully interactive, plotly.js inlined, zero external requests.
Three levels of interactivity, in increasing order of what they ask of the author.
using PlotlyJS, SlatePlotly
"PlotlyJS $(pkgversion(PlotlyJS)) · SlatePlotly $(pkgversion(SlatePlotly))""PlotlyJS 0.18.18 · SlatePlotly 0.1.0"
# Monthly inflation: a mild cycle plus one sharp episode, narrow enough that the smoothing window
# visibly changes the peak.
xs = collect(1:60)
infl = round.(2.0 .+ 0.45 .* sin.(xs ./ 3.2) .+ 0.25 .* cos.(xs ./ 1.7) .+
4.5 .* exp.(-((xs .- 31.0) .^ 2) ./ (2 * 1.1^2)); digits = 2)
# Centred moving average; the window shrinks at the ends rather than dropping points, so the smoothed
# series spans the whole range.
function movavg(v, w)
half = (w - 1) ÷ 2
[begin
lo, hi = max(1, i - half), min(length(v), i + half)
round(sum(view(v, lo:hi)) / (hi - lo + 1); digits = 2)
end for i in eachindex(v)]
end
"$(length(xs)) months · peak $(maximum(infl))%""60 months · peak 6.59%"
A @bind control that still works with no kernel
The control is an ordinary Slate @bind — it looks and behaves like every other control in the
notebook, and other cells can read w too.
Live, moving the slider re-runs the cell below in Julia: movavg(infl, w) is genuinely recomputed.
In a standalone export there is no kernel, so @replay computes the series for every position the
control can take, packs them as one binary array, and ships it with the page. Moving the slider then
indexes into that data.
The whole extra ask is the macro. The author never names a trace or a field, and never restates the domain — it is read from the control, so it cannot drift out of sync with it.
# The smoothed series is REPLAYABLE: `@replay` computes it for every position the slider can take, ships
# the lot as one packed binary array, and returns the slice for the current `w`. Live this is ordinary
# Julia; in a standalone export the slider indexes the shipped data instead.
#
# Note what is NOT written here: the domain. It comes from the control itself, so it cannot drift.
Plot([scatter(x = xs, y = infl, mode = "lines", name = "monthly",
line = attr(width = 1), opacity = 0.45),
scatter(x = xs, y = @replay(w, movavg(infl, w)), mode = "lines",
name = "smoothed", line = attr(width = 3))],
Layout(title = "Centred moving average",
height = 420,
xaxis = attr(title = "month"),
yaxis = attr(title = "inflation (%)", range = [0, 7])))Pushing on it — a matrix, and a different kind of control
@replay is not limited to a series or to a slider. Here a whole matrix (a heatmap's z) is
replayed, driven by a Select. The slices stack along a new trailing dimension, so one control value is
still a contiguous run in the shipped buffer — the page takes a view and reshapes it, never a gather.
field(k) = [k == "gaussian" ? exp(-((x - 40)^2 + (y - 30)^2) / 320) :
k == "ripple" ? sin(hypot(x - 40, y - 30) / 4) :
k == "saddle" ? ((x - 40)^2 - (y - 30)^2) / 1600 :
-exp(-((x - 40)^2 + (y - 30)^2) / 900)
for y in 1:60, x in 1:80]
Plot(heatmap(z = @replay(kern, field(kern)), colorscale = "Viridis"),
Layout(title = "60 × 80 field, replayed across 4 options", height = 420,
xaxis = attr(title = "x"), yaxis = attr(title = "y")))How far does this go?
The cost is the whole shipped array: points × domain size × 8 bytes for Float64. That is the only
limit — there is no per-position figure, no duplicated spec, and nothing recomputed at export time.
Below: a 2 000-point series over a 100-position slider. That is 200 000 values, 1.6 MB packed — and the control still moves at full speed, because switching position is a contiguous read, not a recomputation.
What survives the export
Export with Offline ticked for a single file with no external subresources. When the notebook has
@replay marks, a second dialog appears first: each one lists what it will compute and how many bytes
it will carry — measured from a real value, not estimated — and lets you lower a slider's resolution to
carry less. That choice is stored in the notebook, so it travels with the file.
| In the exported file | |
|---|---|
| Plotly figures | live — hover, legend, zoom/pan, modebar |
plotly.js itself | inlined from a version-pinned artifact |
A @bind with @replay data | live — the control indexes shipped data |
A @bind with no replay data | rendered, but disabled — visibly inert rather than silently dead |
Any other @bind reactivity | not available; it needs the kernel |
A control is enabled only when data for it actually shipped, so a reader can tell at a glance which knobs still do something.
What it costs
The shipped array is points × values × 4 bytes once narrowed to 32-bit at export. The three figures
above carry ~1.7 MB at full resolution; at every 5th on the largest one, ~470 kB. Compression is
optional — it saves roughly a further 18% but needs a 2023-or-newer browser to read, so a page bound for
an old locked-down machine should leave it off.